aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/pages/og/[slug].jpg.ts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-04 23:10:00 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-04 23:10:00 +0900
commit680cccf3ed83578c387f52ff36b0d762f9c869ee (patch)
treee523cf76cd213f854f3fd38ff1e5ee2282e9824c /src/pages/og/[slug].jpg.ts
parent69d3cad77290edbb57b539a674ec0199a528f1cc (diff)
ogp: 星空背景のデザインに刷新
背景をsharpで生成した星空に変更し、名前を大きくして 小さい表示でも読みやすくした。長いタイトルの自動縮小、 ホームのタグライン重複解消、キャッシュ更新(v=3)も含む。
Diffstat (limited to 'src/pages/og/[slug].jpg.ts')
-rw-r--r--src/pages/og/[slug].jpg.ts402
1 files changed, 235 insertions, 167 deletions
diff --git a/src/pages/og/[slug].jpg.ts b/src/pages/og/[slug].jpg.ts
index a34ae74..ca41c4a 100644
--- a/src/pages/og/[slug].jpg.ts
+++ b/src/pages/og/[slug].jpg.ts
@@ -10,6 +10,8 @@ import {
} from "node:fs";
import path from "node:path";
+const W = 1200;
+const H = 630;
const contentDir = "src/content/docs";
const pages: Record<string, string> = {
@@ -23,6 +25,7 @@ const pages: Record<string, string> = {
"textlint-guide": "textlintで文章校正する方法",
"bluebey": "ぶるべー スタジオ",
"koubunsyo-kanri": "公文書管理の不正追及の軌跡",
+ "gyosei-fufuku-sinsa": "行政不服審査会の問題追及の軌跡",
"ijime-judai-jitai": "いじめ重大事態への対応の軌跡",
"fukushi-shisetsu-gyakutai": "障害者福祉施設における虐待通報対応の軌跡",
"aiki-kouen": "合気公園の軌跡",
@@ -83,45 +86,215 @@ const fontBuffer = readFileSync("node_modules/.noto-sans-jp.otf");
const faceiconBuffer = readFileSync("public/img/faceicon.jpg");
const faceiconBase64 = `data:image/jpeg;base64,${faceiconBuffer.toString("base64")}`;
-// Generate star elements for space-like background
-const stars = [
- { x: 80, y: 40, r: 2, o: 0.8 },
- { x: 200, y: 90, r: 1.5, o: 0.6 },
- { x: 350, y: 30, r: 2.5, o: 0.9 },
- { x: 500, y: 80, r: 1, o: 0.5 },
- { x: 650, y: 50, r: 2, o: 0.7 },
- { x: 800, y: 100, r: 1.5, o: 0.6 },
- { x: 950, y: 40, r: 3, o: 0.85 },
- { x: 1100, y: 70, r: 1, o: 0.5 },
- { x: 1150, y: 120, r: 2, o: 0.7 },
- { x: 150, y: 160, r: 1, o: 0.4 },
- { x: 450, y: 140, r: 2, o: 0.5 },
- { x: 700, y: 160, r: 1.5, o: 0.45 },
- { x: 1000, y: 150, r: 2.5, o: 0.55 },
- { x: 50, y: 500, r: 1.5, o: 0.4 },
- { x: 300, y: 550, r: 2, o: 0.5 },
- { x: 600, y: 520, r: 1, o: 0.35 },
- { x: 850, y: 570, r: 2.5, o: 0.5 },
- { x: 1050, y: 540, r: 1, o: 0.4 },
- { x: 1150, y: 580, r: 1.5, o: 0.45 },
- { x: 120, y: 300, r: 1, o: 0.3 },
- { x: 1080, y: 280, r: 1.5, o: 0.35 },
-].map((s, i) => ({
- type: "div" as const,
- props: {
- key: `star-${i}`,
- style: {
- position: "absolute" as const,
- left: `${s.x}px`,
- top: `${s.y}px`,
- width: `${s.r * 2}px`,
- height: `${s.r * 2}px`,
- borderRadius: "50%",
- background: `rgba(255,255,255,${s.o})`,
- boxShadow: `0 0 ${s.r * 3}px rgba(255,255,255,${s.o * 0.4})`,
+const NAME = "小平市議 安竹洋平";
+const LABEL = "KODAIRA CITY COUNCIL";
+const TAGLINE = "だれもが しあわせに 暮らせるまちへ";
+
+// 星空背景は全ページ共通なので、ビルド中に1回だけ生成して使い回す。
+function mulberry32(a: number) {
+ return function () {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const baseGradient = () =>
+ Buffer.from(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#080b1c"/><stop offset="0.32" stop-color="#10163b"/><stop offset="0.66" stop-color="#23246e"/><stop offset="1" stop-color="#4b3aa8"/></linearGradient></defs><rect width="${W}" height="${H}" fill="url(#g)"/></svg>`,
+ );
+
+const nebulaSvg = () =>
+ Buffer.from(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><ellipse cx="150" cy="80" rx="450" ry="320" fill="#6366f1" opacity="0.62"/><ellipse cx="1090" cy="600" rx="490" ry="340" fill="#06b6d4" opacity="0.55"/><ellipse cx="900" cy="70" rx="370" ry="260" fill="#8b5cf6" opacity="0.5"/><ellipse cx="640" cy="360" rx="360" ry="220" fill="#3b82f6" opacity="0.22"/></svg>`,
+ );
+
+const milkyWay = () =>
+ Buffer.from(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><g transform="rotate(-24 600 315)"><ellipse cx="600" cy="315" rx="780" ry="95" fill="#93c5fd" opacity="0.38"/><ellipse cx="600" cy="315" rx="720" ry="42" fill="#ffffff" opacity="0.22"/></g></svg>`,
+ );
+
+const arcsSvg = () =>
+ Buffer.from(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><circle cx="985" cy="330" r="360" fill="none" stroke="#c7d2fe" stroke-opacity="0.18" stroke-width="1.5"/><circle cx="985" cy="330" r="472" fill="none" stroke="#a5f3fc" stroke-opacity="0.12" stroke-width="1"/></svg>`,
+ );
+
+function starsSvg(seed: number, n: number) {
+ const rnd = mulberry32(seed);
+ let s = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><defs><radialGradient id="sg"><stop offset="0" stop-color="#ffffff" stop-opacity="0.95"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></radialGradient></defs>`;
+ for (let i = 0; i < n; i++) {
+ const x = rnd() * W;
+ const y = rnd() * H;
+ const r = 0.35 + rnd() * 1.8;
+ const o = 0.18 + rnd() * 0.75;
+ const col = rnd() > 0.82 ? "#dbeafe" : "#ffffff";
+ s += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(2)}" fill="${col}" opacity="${o.toFixed(2)}"/>`;
+ }
+ for (let i = 0; i < 8; i++) {
+ const x = 520 + rnd() * 660;
+ const y = 40 + rnd() * 550;
+ const size = 5 + rnd() * 9;
+ const o = 0.6 + rnd() * 0.38;
+ const col = rnd() > 0.5 ? "#e0f2fe" : "#ffffff";
+ s += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(size * 3).toFixed(1)}" fill="url(#sg)" opacity="${(o * 0.45).toFixed(2)}"/>`;
+ const q = size * 0.22;
+ s += `<path d="M ${x} ${y - size} L ${x + q} ${y - q} L ${x + size} ${y} L ${x + q} ${y + q} L ${x} ${y + size} L ${x - q} ${y + q} L ${x - size} ${y} L ${x - q} ${y - q} Z" fill="${col}" opacity="${o.toFixed(2)}"/>`;
+ }
+ s += `</svg>`;
+ return Buffer.from(s);
+}
+
+const leftOverlay = () =>
+ Buffer.from(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><defs><linearGradient id="l" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#05070f" stop-opacity="0.86"/><stop offset="0.4" stop-color="#05070f" stop-opacity="0.62"/><stop offset="0.62" stop-color="#05070f" stop-opacity="0.22"/><stop offset="0.8" stop-color="#05070f" stop-opacity="0"/></linearGradient></defs><rect width="${W}" height="${H}" fill="url(#l)"/></svg>`,
+ );
+
+async function buildBackground(): Promise<Buffer> {
+ const base = await sharp(baseGradient()).png().toBuffer();
+ const neb = await sharp(nebulaSvg()).blur(80).png().toBuffer();
+ const mw = await sharp(milkyWay()).blur(55).png().toBuffer();
+ return sharp(base)
+ .composite([
+ { input: neb },
+ { input: mw, blend: "screen" },
+ { input: arcsSvg() },
+ { input: starsSvg(20261104, 260) },
+ { input: leftOverlay() },
+ ])
+ .png()
+ .toBuffer();
+}
+
+let backgroundPromise: Promise<Buffer> | null = null;
+function getBackground(): Promise<Buffer> {
+ if (!backgroundPromise) backgroundPromise = buildBackground();
+ return backgroundPromise;
+}
+
+function textLayer(title: string) {
+ // 長いタイトルでもはみ出さないよう、文字数に応じて少しだけ縮める
+ const titleSize = title.length > 40 ? 46 : title.length > 30 ? 52 : 60;
+ // ホームはタイトルとタグラインが実質同じなので、タグラインを省く
+ const isHome =
+ title.replace(/\s/g, "") === TAGLINE.replace(/\s/g, "");
+
+ return {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ width: `${W}px`,
+ height: `${H}px`,
+ alignItems: "center",
+ padding: "64px 80px",
+ gap: "56px",
+ position: "relative",
+ fontFamily: "Noto",
+ color: "#ffffff",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ padding: "6px",
+ borderRadius: "50%",
+ background: "linear-gradient(135deg,#22d3ee,#818cf8)",
+ flexShrink: "0",
+ },
+ children: [
+ {
+ type: "img",
+ props: {
+ src: faceiconBase64,
+ width: 168,
+ height: 168,
+ style: { borderRadius: "50%" },
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "18px",
+ flex: "1",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "22px",
+ letterSpacing: "3.5px",
+ color: "rgba(214,224,255,0.85)",
+ },
+ children: LABEL,
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ lineHeight: 1.16,
+ letterSpacing: "-0.01em",
+ },
+ children: title,
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ width: "128px",
+ height: "7px",
+ borderRadius: "4px",
+ background: "linear-gradient(90deg,#22d3ee,#818cf8)",
+ },
+ },
+ },
+ ...(isHome
+ ? []
+ : [
+ {
+ type: "div",
+ props: {
+ style: { fontSize: "30px", color: "#c7d2fe" },
+ children: TAGLINE,
+ },
+ },
+ ]),
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ alignItems: "center",
+ paddingTop: "16px",
+ borderTop: "1px solid rgba(255,255,255,0.22)",
+ fontSize: "31px",
+ fontWeight: 700,
+ color: "#ffffff",
+ },
+ children: [{ type: "div", props: { children: NAME } }],
+ },
+ },
+ ],
+ },
+ },
+ ],
},
- },
-}));
+ };
+}
export async function GET({ params }: { params: { slug: string } }) {
const slug = params.slug;
@@ -159,7 +332,11 @@ export async function GET({ params }: { params: { slug: string } }) {
}
const { createHash } = await import("node:crypto");
- const titleHash = createHash("md5").update(title).digest("hex");
+ // デザインを変えたらこのバージョンを上げる(キャッシュを無効化する)
+ const DESIGN_VERSION = "space-v9b-3";
+ const titleHash = createHash("md5")
+ .update(`${DESIGN_VERSION}|${title}`)
+ .digest("hex");
if (existsSync(cacheFile) && cache[slug] === titleHash) {
const cachedBuffer = readFileSync(cacheFile);
@@ -171,135 +348,26 @@ export async function GET({ params }: { params: { slug: string } }) {
});
}
- const svg = await satori(
- {
- type: "div",
- props: {
- style: {
- display: "flex",
- width: "1200px",
- height: "630px",
- background:
- "linear-gradient(135deg, #0f0d2e 0%, #1e1b4b 30%, #3730a3 60%, #4f46e5 100%)",
- color: "#ffffff",
- fontFamily: "sans-serif",
- padding: "64px 80px",
- alignItems: "center",
- gap: "56px",
- position: "relative",
- overflow: "hidden",
- },
- children: [
- ...stars,
- {
- type: "img",
- props: {
- src: faceiconBase64,
- width: 180,
- height: 180,
- style: {
- borderRadius: "50%",
- border: "3px solid rgba(255,255,255,0.25)",
- flexShrink: "0",
- position: "relative",
- },
- },
- },
- {
- type: "div",
- props: {
- style: {
- display: "flex",
- flexDirection: "column",
- gap: "20px",
- flex: "1",
- position: "relative",
- },
- children: [
- {
- type: "div",
- props: {
- style: {
- fontSize: "64px",
- fontWeight: "700",
- lineHeight: "1.2",
- letterSpacing: "-0.02em",
- },
- children: title,
- },
- },
- {
- type: "div",
- props: {
- style: {
- display: "flex",
- gap: "14px",
- fontSize: "32px",
- fontWeight: "500",
- color: "#a5b4fc",
- },
- children: [
- { type: "div", props: { children: "だれもが" } },
- {
- type: "div",
- props: {
- style: { color: "#c7d2fe" },
- children: "しあわせに",
- },
- },
- {
- type: "div",
- props: {
- style: { color: "#e0e7ff" },
- children: "暮らせるまちへ",
- },
- },
- ],
- },
- },
- {
- type: "div",
- props: {
- style: {
- display: "flex",
- gap: "16px",
- fontSize: "26px",
- fontWeight: "400",
- color: "rgba(255,255,255,0.45)",
- paddingTop: "12px",
- borderTop: "1px solid rgba(255,255,255,0.1)",
- },
- children: [
- { type: "div", props: { children: "小平市議 安竹洋平" } },
- {
- type: "div",
- props: {
- style: { opacity: "0.6" },
- children: "yasutakeyohei.com",
- },
- },
- ],
- },
- },
- ],
- },
- },
- ],
- },
- } as any,
- {
- width: 1200,
- height: 630,
- fonts: [
- { name: "sans-serif", data: fontBuffer, weight: 400, style: "normal" },
- ],
- },
- );
+ const bg = await getBackground();
+
+ const svg = await satori(textLayer(title) as any, {
+ width: W,
+ height: H,
+ fonts: [
+ { name: "Noto", data: fontBuffer, weight: 400, style: "normal" },
+ { name: "Noto", data: fontBuffer, weight: 700, style: "normal" },
+ ],
+ });
+
+ const pngBuffer = new Resvg(svg, { fitTo: { mode: "width", value: W } })
+ .render()
+ .asPng();
- const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } });
- const pngBuffer = resvg.render().asPng();
- // OGP は JPEG で出力する(PNGだと1枚300KBを超えるため、JPEGで約1/5にする)
- const jpgBuffer = await sharp(pngBuffer).jpeg({ quality: 88 }).toBuffer();
+ // テキスト層を星空背景に重ねて JPEG で出力する
+ const jpgBuffer = await sharp(bg)
+ .composite([{ input: pngBuffer }])
+ .jpeg({ quality: 88 })
+ .toBuffer();
// Save to cache in node_modules/.og-cache (persists across builds)
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });